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 thecanvas_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.
Why one grant covers all roles
Why one grant covers all roles
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.
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.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.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.
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.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.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.