diff --git a/src/helpers/genRouteHandler.ts b/src/helpers/genRouteHandler.ts index ec32b8f..9ce8a8f 100644 --- a/src/helpers/genRouteHandler.ts +++ b/src/helpers/genRouteHandler.ts @@ -370,13 +370,24 @@ const genRouteHandler = ( /* ------------- Launch Info ------------ */ /*----------------------------------------*/ - // Get launch info - const { launched, launchInfo } = getLaunchInfo(req); + // Get launch info. Public-tier routes (/api/public) are anonymous by + // definition: a live launched session must neither inject identity or + // roles into a public request nor block it via the course consistency + // check below (mirrors the /api/ttm and /api/admin path conventions) + const isPublicPath = req.path.startsWith('/api/public'); + const { launched, launchInfo } = ( + isPublicPath + ? { launched: false, launchInfo: undefined as any } + : getLaunchInfo(req) + ); if ( // Not launched (!launched || !launchInfo) // Not skipping the session check && !skipSessionCheck + // No verified per-request course auth (a verified auth is itself proof + // of a Canvas-verified launch and may outlive the shared session) + && !req.verifiedCourseAuth ) { return handleError( res, @@ -432,6 +443,8 @@ const genRouteHandler = ( ) // Not skipping the session check && !skipSessionCheck + // No verified per-request course auth (see note above) + && !req.verifiedCourseAuth ) { return handleError( res, @@ -516,13 +529,70 @@ const genRouteHandler = ( } }); + /*----------------------------------------*/ + /* -------- Verified Course Auth --------- */ + /*----------------------------------------*/ + + // If the consumer app attached an already-verified per-request course + // authorization (see types/VerifiedCourseAuth), trust it over the single + // shared session for this request's course, roles, and identity + if (req.verifiedCourseAuth) { + // Make sure students don't act as other students (mirrors the + // launchInfo-based check above, which cannot run when the session is + // gone and reflects another course's roles when the session holds a + // different course) + if ( + req.verifiedCourseAuth.isLearner + && output.userId + && output.userId !== req.verifiedCourseAuth.userId + ) { + return handleError( + res, + { + message: 'We encountered a student ID mismatch. Please refresh or try the action again. Contact support if this issue persists.', + code: ExpressKitErrorCode.StudentIdMismatch, + status: 401, + }, + ); + } + + // Course + role flags come from the verified (course-specific) auth + output.courseId = req.verifiedCourseAuth.courseId; + output.isLearner = req.verifiedCourseAuth.isLearner; + output.isTTM = req.verifiedCourseAuth.isTTM; + output.isAdmin = req.verifiedCourseAuth.isAdmin; + + // Identity comes from the verified auth when the session cannot supply + // it (params still win, matching the launchInfo behavior above) + output.userId = (output.userId ?? req.verifiedCourseAuth.userId); + output.userFirstName = ( + output.userFirstName + ?? req.verifiedCourseAuth.userFirstName + ); + output.userLastName = ( + output.userLastName + ?? req.verifiedCourseAuth.userLastName + ); + output.userEmail = ( + output.userEmail + ?? req.verifiedCourseAuth.userEmail + ); + output.userAvatarURL = ( + output.userAvatarURL + ?? 'http://www.gravatar.com/avatar/?d=identicon' + ); + } + /*----------------------------------------*/ /* ----- Require Course Consistency ----- */ /*----------------------------------------*/ // Make sure the user actually launched from the appropriate course if ( - output.courseId + // No verified per-request course auth (a verified auth is itself + // proof the user launched this course) + !req.verifiedCourseAuth + && output.courseId && launchInfo && launchInfo.courseId && output.courseId !== launchInfo.courseId diff --git a/src/index.ts b/src/index.ts index f0575c2..65cbdad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,7 @@ import initExpressKitCollections, { getLogCollection } from './helpers/initExpre // Import types import CrossServerCredential from './types/CrossServerCredential'; +import VerifiedCourseAuth from './types/VerifiedCourseAuth'; // Export each item export { @@ -125,6 +126,7 @@ export { LogMetadataType, LogFunction, CrossServerCredential, + VerifiedCourseAuth, // Server types ParamType, }; diff --git a/src/types/VerifiedCourseAuth.ts b/src/types/VerifiedCourseAuth.ts new file mode 100644 index 0000000..49d3668 --- /dev/null +++ b/src/types/VerifiedCourseAuth.ts @@ -0,0 +1,50 @@ +/** + * Per-request verified course authorization. + * + * A consumer app sets req.verifiedCourseAuth ONLY after it has + * cryptographically verified that the requester is authorized for a specific + * course with specific roles (for example, via a signed per-tab course token + * minted at a Canvas-verified launch). When present, genRouteHandler prefers + * it over the single shared session's launchInfo for that request's course, + * roles, and identity — which is what lets multiple browser tabs work on + * different courses despite sharing one session, and lets a verified request + * outlive the session itself. When absent, behavior is unchanged. + * @author Gabe Abrams + */ +type VerifiedCourseAuth = { + // The course the requester is verified for + courseId: number, + // The user the verification was issued to + userId: number, + // True if the user is a plain course member in this course + isLearner: boolean, + // True if the user is a teaching team member in this course + isTTM: boolean, + // True if the user is a Canvas admin + isAdmin: boolean, + // Section ids of the user's launch into this course (from the launch's + // custom section_ids param), captured by the consumer at verification + // time so they stay faithful to THIS course even after the shared session + // moves on. Optional: consumers that don't use sections may omit it + sectionIds?: number[], + // The user's first name (optional: used when the session cannot supply it) + userFirstName?: string, + // The user's last name (optional: used when the session cannot supply it) + userLastName?: string, + // The user's email (optional: used when the session cannot supply it) + userEmail?: string, +}; + +// Augment Express so consumers can attach a verified auth to the request +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + // Per-request verified course authorization (set by the consumer app + // only after cryptographic verification) + verifiedCourseAuth?: VerifiedCourseAuth, + } + } +} + +export default VerifiedCourseAuth;