Routes marked with a compatibility contract —
GET /lti/config, GET|POST /lti/login, GET|POST /lti/launch, and GET /.well-known/jwks.json — are stable public surfaces. Canvas registrations and SEB clients depend on them remaining at the same paths across upgrades.Service Status and Canvas Fallback
GET /
Returns the Safe Online Exam service-status shell page. In normal operation this route is the application root; it renders a lightweight React view that includes optional testbed diagnostics when the testbed feature is enabled.
Caller: Browser, operator, health monitoring.
Auth: None.
Response: 200 text/html — the React app shell with view service-status.
GET /login
Returns a plain HTML fallback page that instructs users to launch the tool from Canvas. Canvas may redirect unauthenticated users here when an existing session cookie has expired or when a browser navigates directly to the tool URL.
Caller: Browser (direct navigation or Canvas redirect).
Auth: None.
Response: 200 text/html — static fallback instructing the user to open Canvas.
Health Endpoints
The service exposes three independent health routes covering the HTTP layer, the login path, and the static JavaScript delivery path. All three return the same lightweight JSON body.- GET /health
- GET /login/health
- GET /js/health
Purpose: Primary liveness check. Used by load balancers, Cloud Run, and Docker Compose health checks.Caller: Infrastructure health probes.Auth: None.Response:
Setup Handoff Pages
GET /setup and GET /setup/guide
Render a role-oriented setup guide for Canvas administrators installing the tool. Both routes return the React app shell with view admin-setup. The /setup/guide variant enables a more detailed walkthrough mode (detailed: true); /setup shows the summary view.
The app shell includes the service’s current toolUrl, healthUrl, ltiConfigUrl, jwksUrl, and detectorUrl values so that the person reading the page can copy the correct endpoint URLs into Canvas without referencing separate documentation.
Caller: Browser (Canvas or service administrator during initial configuration).
Auth: None.
Response: 200 text/html — React app shell with view admin-setup.
LTI Signing Keys
GET /.well-known/jwks.json
Returns the service’s active RSA public key set in JSON Web Key Set format. Canvas fetches this endpoint when validating the tool’s registration and when verifying any signed assertion the service issues during the OIDC flow.
Caller: Canvas LTI platform (during developer key registration and on-demand key refresh).
Auth: None — this endpoint is intentionally public.
Compatibility contract: This path must remain stable. Canvas caches the JWKS URL from the tool’s developer key registration; changing the path requires updating every Canvas installation.
Response:
array
required
Array of public JWK objects. The service only publishes RSA keys. Each key has at minimum
kty, alg, use, kid, n, and e fields. The private exponent d is never included.LTI Configuration Document
GET /lti/config
Returns the Canvas LTI 1.3 JSON configuration document that a Canvas administrator uses to register or update the tool’s developer key. The document describes both the course navigation placement (for instructors and students) and the account navigation placement (for root-account administrators).
Caller: Canvas administrator during developer key setup, or automated registration tooling.
Auth: None — this document contains only public tool metadata.
Compatibility contract: This path must remain stable.
Response: 200 application/json
Full configuration document shape
Full configuration document shape
visibility of the course navigation placement is controlled by the LTI_COURSE_NAVIGATION_VISIBLE_TO_STUDENTS configuration flag. When set to false, the placement is visible only to Canvas admins and teachers; when unset or true, it is visible to all course members.
Custom fields requested per placement:
- Course placement
- Account admin placement
OIDC Initiation
GET /lti/login and POST /lti/login
Handles the LTI 1.3 OIDC third-party initiation request. Canvas sends this request before the signed launch to establish a nonce and state token that the service will later verify at /lti/launch. Both GET and POST methods are accepted; the service normalizes query parameters and body fields identically.
Caller: Canvas LTI platform — the first step in every LTI launch.
Auth: None at this stage (the request is from Canvas, not a logged-in user). The service validates issuer and client ID before creating state.
Compatibility contract: This path must remain stable.
Rate limiting: Process-local and distributed IP-based admission budgets apply. Exceeding the budget returns 429 with a retry-after: 60 header.
Request parameters (query string for GET, form body for POST):
string
required
Canvas issuer URL. Must exactly match the configured
LTI_ISSUER value (e.g., https://canvas.instructure.com).string
required
Opaque Canvas user hint used to pre-select the user in Canvas’s authorization step. Passed through unchanged to Canvas.
string
required
The URI Canvas wants the service to launch. Must match the tool origin and be either
/lti/launch or a /seb/launch/:contentId path. Maximum 2048 characters.string
Optional Canvas LTI client ID. When present, must match
LTI_CLIENT_ID.string
Optional Canvas deployment ID. When deployment ID checking is enabled (
LTI_DEPLOYMENT_ID_CHECKING_ENABLED is not false), this value must appear in the LTI_DEPLOYMENT_ID allowlist. When absent and checking is enabled, the login is rejected.string
Optional Canvas message hint forwarded unchanged to the Canvas authorization URL. Maximum 8192 characters.
- Validates envelope size bounds on all parameters.
- Verifies
iss, optionalclient_id,target_link_uri, and optionallti_deployment_id. - Generates a cryptographic nonce (
randomUUID()). - Creates a browser transaction cookie: a short-lived
HttpOnlysecure cookie containing a transaction ID and binding hash, scoped to the launch lifetime. - Encrypts and stores state (nonce, target URI, issuer, transaction binding, optional deployment ID) in PostgreSQL with a 10-minute TTL.
- Redirects
302to Canvas’s OIDC authorization endpoint with:scope=openidresponse_type=id_tokenresponse_mode=form_postprompt=noneredirect_uri=<toolUrl>/lti/launchnonce,state,login_hint, and any forwarded hints
Signed LTI Launch
POST /lti/launch
Receives and validates the Canvas-signed LTI ID token after the OIDC authorization step. This is the most security-critical endpoint in the service; it performs the complete validation chain before creating a verified LTI principal in the session.
Caller: Canvas LTI platform — Canvas POSTs an HTML form to this URL as the final step of every LTI launch.
Auth: Canvas RS256-signed ID token (validated by the service against the configured Canvas JWKS).
Compatibility contract: This path must remain stable.
Rate limiting: Process-local and distributed IP-based budgets apply independently from the login budget.
Request body (form POST):
string
required
The RS256-signed LTI ID token issued by Canvas. The service validates signing, issuer, audience, nonce, token age, timestamps, LTI version and message type, deployment ID, target link URI, and the OIDC state tuple.
string
required
The encrypted state token created during
/lti/login. Used to look up the stored nonce, target URI, and browser transaction binding.string
When Canvas itself encounters an error, it POSTs an
error field instead of an ID token. The service returns 400 with an explanatory HTML page.1
Envelope check
Verifies that
state and id_token meet bounded-size requirements before any cryptographic work.2
Rate admission
Consumes process-local and distributed IP admission budgets. Returns
429 if exhausted.3
State peek
Decrypts the
state token from PostgreSQL to retrieve the stored nonce and browser transaction binding. The state record has not been consumed yet.4
Browser transaction cookie
Verifies that the browser’s transaction cookie matches the binding hash stored in state. Fails if the cookie is absent (e.g., blocked third-party cookies) or does not match.
5
Distributed state admission
Consumes a per-state distributed admission budget before proceeding to token validation, preventing distributed brute-force attempts on a single state token.
6
Token validation
Validates the ID token RS256 signature against the Canvas JWKS, then checks issuer, audience, nonce, token age, issued/expiry timestamps, LTI version, message type, and deployment ID.
7
Login–launch binding
Confirms that the token’s issuer, deployment ID, and target link URI match the values stored in the OIDC state. Prevents state substitution between logins.
8
Atomic state claim
Atomically marks the state token as consumed in PostgreSQL. Prevents replay of the same state/token pair from a second browser.
9
Session regeneration
Calls Express
regenerateSession to issue a fresh session ID before writing any principal, preventing session fixation.10
Principal creation
Creates a
VerifiedLtiPrincipal from the validated launch data — either a course principal (instructor/student) or an account admin principal — and writes it to the new session.
Error responses:
GET /lti/launch
Handles same-origin browser reloads and redirects within an active verified session. Canvas never calls this route directly — it is used by the React client when the user navigates within the already-authenticated tool (e.g., after Canvas OAuth completes and the tool redirects back with ?connected=1).
Caller: Browser (same-origin navigation within an active tool session).
Auth: Existing verified LTI principal in the Express session. Returns 302 /login if no principal is present.
Fetch Metadata guard: Requests where sec-fetch-dest is iframe and sec-fetch-site is cross-site are rejected with 403. This prevents Canvas from re-entering a live session through a cross-site iframe navigation.
Query parameters:
string
Optional. When present, must exactly match
principal.courseId. Returns 403 on mismatch.string
Optional. When present, must exactly match
principal.canvasUserId. Returns 403 on mismatch.string
Optional. Signal from the OAuth callback that Canvas authorization just completed. Not validated; the role-routing logic re-reads the principal from session.
POST /lti/launch but sources the principal entirely from the existing session rather than from a new token.