Skip to main content
Safe Online Exam uses a separate Canvas OAuth 2.0 authorization grant to make Canvas REST and New Quiz API calls on behalf of instructors, students, and root-account administrators. An LTI launch identifies and authenticates a person, but it does not authorize API access — the OAuth flow provides that second capability. This page documents every route under the Canvas OAuth routes section, the security controls each endpoint enforces, the state it creates or consumes, and how the single shared grant model works.

Canvas OAuth Grant Model

Understanding the grant model is essential before reading the individual endpoints. Safe Online Exam maintains one durable OAuth token record per Canvas user ID in the canvas_oauth_tokens table. It never creates a second record for the same user. When an administrator upgrades an existing instructor grant with account scopes, the same row is updated in-place to hold the expanded scope set.
Every Canvas OAuth connection — instructor, student session, and administrator — requests the same complete application scope set. This includes url:GET|/api/v1/courses (used for teacher-scoped tool duplication) and url:GET|/api/v1/login/session_token (required for SEB session handoff). The product decision is explicit: a single grant must remain valid when a person is an instructor in one course and a student in another, and when that same person later becomes a root-account administrator.Canvas still enforces the user’s actual course and account permissions for every API call. Possessing a broad scope set does not grant access the user does not hold in Canvas. The application never treats scope possession as an LTI role check or course-authorization check.
Scope sets in use: If a user already holds an administrator grant and then reauthorizes as an instructor, the service detects the existing admin scope profile and issues the full admin scope set again rather than replacing it with a narrower grant. This prevents accidental scope downgrade. Token encryption at rest: Access and refresh tokens are encrypted before PostgreSQL persistence using AES-256-GCM with an independent keyring. The record ID and Canvas user ID are authenticated as associated data (AAD). Nonsecret scope and identity metadata remains in plaintext columns for queryability. Legacy plaintext rows written before the encryption keyring was provisioned are read in a compatibility mode and are rewritten by the explicit migrate-tokens maintenance command.
The OAuth callback never renders or redirects into an authenticated management view. After code exchange and token storage, instructor and administrator callbacks render a non-privileged completion page that signals the same-origin opener window. The opener verifies both the message origin and the popup window reference before performing a same-origin refresh. If a popup is unavailable the user returns to Canvas manually. Student callbacks render a connected page that provides a local return URL derived from the signed LTI session, not from query parameters.

Authorization Endpoints

All three authorization-start endpoints enforce the same Fetch Metadata guard before doing anything else.
Requests whose sec-fetch-site header is present and set to any value other than same-origin are rejected with 403 Open Canvas authorization from the Safe Online Exam tool. This prevents third-party sites from initiating an OAuth authorization by embedding or linking to these endpoints.

GET /api/oauth2authorize

Starts a Canvas OAuth authorization flow for an instructor. Redirects to Canvas to request the standard application scope set (or the full admin set if the user already holds an admin grant). Caller: The Safe Online Exam React UI, as a same-origin Fetch or navigation triggered by the instructor in the course management view. Auth required: A verified LTI principal with the Instructor (or TeacherEnrollment, DesignerEnrollment) role. A student or unauthenticated request is rejected. When to use: Called when the instructor launches the tool and no access token is on file, or when the token has been revoked and the tool cannot recover automatically. Query parameters:
string
Optional. When present, must exactly match the course ID in the verified LTI principal. A mismatch returns 403.
string
Optional. When present, must exactly match the Canvas user ID in the verified LTI principal. A mismatch returns 403.
string
Optional. A local path to return to after authorization completes (used by the popup flow). Must be a valid same-origin path; arbitrary external URLs are silently replaced with /lti/launch.
State created: An encrypted state record is written to transient_states with a 10-minute TTL. The record includes: purpose: "canvas-oauth-v2", canvasUserId, ltiSubject, courseId, issuer, deploymentId, a SHA-256 session binding hash, an oauthScopeProfile ("admin" or "application"), and the safe-local redirectUrl. Response: 302 redirect to <canvas-domain>/login/oauth2/auth with client_id, response_type=code, redirect_uri, state, and scope.

GET /api/oauth2reauthorize

Identical behavior to GET /api/oauth2authorize. The separate path allows the client to signal intent (reauthorizing after a revoked or expired grant) without changing the authorization flow. Caller: Same as /api/oauth2authorize — the instructor UI, after detecting a Canvas API authorization error. Auth required: Same as /api/oauth2authorize. State and response: Identical to /api/oauth2authorize.

GET /api/student-session-authorize

Starts the Canvas OAuth authorization flow for a student. The student scope set is the same as the instructor scope set — the product decision described in the grant model applies here too. After authorization, the callback renders a student-connected view with a local return URL derived from the signed LTI session. Caller: The Safe Online Exam React UI (student view), as a same-origin navigation when the student’s Canvas user ID has no session-token access on file. Auth required: A verified LTI principal with a student role (StudentEnrollment). An instructor or unauthenticated request is rejected. When to use: Called when a student launches the tool and the service cannot generate a Canvas session URL because no OAuth token is stored for that user. This is the first time that student has used the tool on this Canvas instance. Return URL derivation: The return URL after authorization is derived only from the signed LTI launch data stored in the session — never from query parameters. If the launch included a direct SEB content target (/seb/launch/:contentId), the return URL is /seb/launch/:contentId?connected=1; otherwise it is /lti/launch?connected=1. This prevents the student OAuth flow from being turned into an open redirect or used to switch courses after consent. State created: Same structure as instructor state with purpose: "canvas-student-session-oauth-v1". Response: 302 redirect to Canvas authorization.

GET /api/admin/oauth2authorize

Starts the Canvas OAuth authorization flow for a root-account administrator, requesting the full combined administrator-plus-application scope set. The resulting grant upgrades the user’s existing token record (whether it was previously instructor-only or had no record) to include all administrator scopes. Caller: The Safe Online Exam React UI (admin dashboard), as a same-origin navigation after a root-account administrator launches the tool and no admin-scoped token is on file. Auth required: A verified LTI principal that is a root-account administrator — the session must carry a principal with both a signed LTI_SYSTEM_ROLE administrator claim and Canvas’s $Canvas.user.isRootAccountAdmin substitution returning true. A course-only instructor principal is rejected with 403. When to use: Called when the admin dashboard detects that no token with the administrator scope profile is stored for the user. An administrator who previously authorized as an instructor is prompted to upgrade here without disconnecting their existing instructor grant. State created: Same structure as other authorization states with purpose: "canvas-admin-oauth-v1" and an additional accountId field bound to the verified root account ID. Response: 302 redirect to Canvas authorization with the combined admin+application scope set.

OAuth Callback

GET /api/oauth2callback

Receives the Canvas authorization code redirect after the user approves (or denies) the OAuth authorization prompt. Validates state, exchanges the code for tokens, verifies token ownership, stores the encrypted grant, and renders a completion page. Caller: Canvas — the browser is redirected to this URL by Canvas after the user authorizes (or denies) the OAuth request. Auth required: An existing verified LTI principal from the same session that initiated authorization. The callback validates that the principal’s canvasUserId, ltiSubject, courseId, issuer, deploymentId, and a SHA-256 session binding hash all match what was stored in the OIDC state record. Compatibility contract: This path must remain stable. Canvas stores the redirect URI when the developer key is registered; changing it requires re-registering every Canvas OAuth application and invalidating all existing grants. Query parameters:
string
The authorization code issued by Canvas. Exchanged server-side for tokens; never exposed to the browser.
string
The encrypted state token created at the authorization-start endpoint. Consumed atomically on first use — a replayed callback is rejected.
string
Canvas sets this when the user denies authorization or an error occurs. When present, the service renders an oauth-error view immediately without attempting code exchange.
string
Optional error description from Canvas, included in the error view when present.
Processing steps:
1

Error check

If error is present, renders the error view and returns immediately. No token exchange is attempted.
2

State peek

Decrypts the state record to read the stored purpose, user binding, and session hash. The state is not consumed yet.
3

Principal verification

Reads the verified LTI principal from the current session. Rejects if absent.
4

Role match

Checks that the session principal’s role matches the stored purpose: canvas-oauth-v2 requires instructor, canvas-student-session-oauth-v1 requires student, canvas-admin-oauth-v1 requires root-account administrator.
5

Full binding verification

Verifies canvasUserId, ltiSubject, courseId, issuer, deploymentId, session binding hash, and (for admin) accountId against the stored state. Any mismatch throws.
6

Atomic state claim

Atomically consumes the state record in PostgreSQL. A replayed callback (same state value) is rejected here.
7

Code exchange

POSTs to <canvas-domain>/login/oauth2/token with grant_type=authorization_code, client_id, client_secret, redirect_uri, and code. The response is size-bounded at 64 KB and has an upstream deadline.
8

Token owner verification

Parses the Canvas token response and confirms that token.user.id matches the verified canvasUserId. Mismatched token ownership rejects the exchange.
9

Grant storage

Writes the AES-256-GCM encrypted access token, refresh token, scope, and metadata to canvas_oauth_tokens. The grant type is "account_admin" when the scope set includes admin scopes, otherwise "instructor". One row per Canvas user ID — upsert semantics preserve the wider scope profile if an admin grant already exists.
10

Completion response

Renders a non-privileged completion page. For students (canvas-student-session-oauth-v1), the view is student-session-connected with a safe-local returnUrl. For instructors and administrators, the view is canvas-oauth-connected with a canvasReturnUrl pointing to the user’s Canvas course or account. The callback never renders or redirects into a management view.
Error response: Any failure in state peek, binding verification, code exchange, or token ownership returns 400 with the oauth-error view.

Authorization Status

GET /api/oauth2status

Returns a lightweight JSON object indicating whether a valid Canvas OAuth token is stored for the current instructor. Used by the React UI to poll or check authorization state without triggering a full page reload. Caller: The Safe Online Exam React UI (instructor course management view), via a same-origin Fetch request. Auth required: A verified LTI principal with the instructor role. Returns { authorized: false } for unauthenticated requests or non-instructor principals rather than an error status, so the client UI can treat this as a readable status check. Query parameters:
string
Optional. When present, must match the Canvas user ID in the verified LTI principal. Returns { authorized: false } on mismatch rather than 403, consistent with this endpoint’s status-check semantics.
Response:
boolean
required
true when a valid, non-expired Canvas OAuth access token is stored for the authenticated instructor’s Canvas user ID. false in all other cases (no token, expired token, non-instructor session, or user ID mismatch).

Security Summary

Fetch Metadata enforcement

All three authorization-start endpoints reject any request whose sec-fetch-site header is present and not same-origin. This prevents cross-site initiation of the OAuth flow.

Session binding

Every state record stores a SHA-256 hash of the Express session ID. The callback verifies this binding before consuming the state, preventing CSRF and cross-session injection.

Token encryption

Access and refresh tokens are encrypted at rest with AES-256-GCM. The record ID and Canvas user ID are authenticated as associated data, ensuring tokens cannot be moved between records.

One grant per user

A single canvas_oauth_tokens row per Canvas user ID prevents grant proliferation. Administrator scope upgrades widen the existing row rather than creating a parallel record.