Implementing LTI 1.3 Launch Without Cookies Using AWS Lambdas: Adhering to the LTI Specification
In the field of EdTech, seamless and secure integrations with Learning Management Systems (LMS) like Canvas, Moodle, and Blackboard are crucial. A major challenge has arisen with browsers phasing out third-party cookies, impacting Learning Tools Interoperability (LTI) 1.3 implementations that traditionally depend on cookies for session management and secure data exchanges. At All the Ducks, we undertook a project to develop a baseline system for our LTI tools that operates without cookies, using AWS serverless technology for a robust and scalable solution. As a software developer working on this project, I’ll share our journey, detailing the problem, official solution, and our AWS-based implementation—all in line with the LTI specifications.
Understanding the Problem
The Role of Cookies in LTI 1.3
Historically, LTI tools have relied on browser cookies to manage state parameters during the launch process. These cookies ensure that the user initiating the authorization workflow is the same one completing it, maintaining a secure and consistent experience.
Impact of Browser Changes
Recent browser updates have introduced stricter policies that block third-party cookies in iframes, posing significant challenges for LTI tools. These tools rely on cookies to validate state parameters during the LTI launch process. Without cookies, the state parameters cannot be matched, making it impossible to confirm the authenticity of the request. This not only increases the risk of security threats like cross-site request forgery (CSRF) but also disrupts the LTI launch entirely, rendering it inoperable.
The Solution
1EdTech developed a specification to overcome this challenge by using the JavaScript postMessage API. This approach requires LMS platforms to include a dedicated shared storage iframe, allowing tools to securely store and retrieve state parameters without relying on cookies. Additionally, the lti_storage_target
parameter is introduced in the Initiation Request to indicate LMS support for the shared storage mechanism and specify the target frame for postMessage events. For comprehensive details, refer to the 1EdTech LTI Third-Party Cookie Solution specifications.
Implementing the Solution with AWS Lambdas
To address blocked third-party cookies, we used AWS Cloud Development Kit (CDK) to build a serverless solution that efficiently abstracts and manages the LTI 1.3 launch process. Our setup includes two AWS Lambda functions deployed through API Gateway, with DynamoDB handling configuration and authentication data storage.
Architecture Overview
A quick flow chart to visualise the logic we are trying to implement
Leveraging AWS CDK for Infrastructure as Code
Using AWS CDK ensures consistency and simplifies deployments across various environments. Our implementation uses a CDK stack to provision all necessary resources with appropriate permissions and configurations. Below is an illustrative example:
export class LtiLaunchStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // DynamoDB Tables const configTable = new dynamodb.Table(this, 'ConfigTable', { tableName: 'LtiConfig', partitionKey: { name: 'deploymentId', type: dynamodb.AttributeType.STRING } }); const authDataTable = new dynamodb.Table(this, 'AuthDataTable', { tableName: 'AuthData', partitionKey: { name: 'stateId', type: dynamodb.AttributeType.STRING } }); // Lambda Function for Initiating Launch const initiateLaunchLambda = new lambda.Function(this, 'InitiateLaunchFunction', { runtime: lambda.Runtime.NODEJS_20_X, handler: 'initiateLaunch.handler', code: lambda.Code.fromAsset('lambda/initiateLaunch'), }); // Lambda Function for Authorizing Callback const authorizeCallbackLambda = new lambda.Function(this, 'AuthorizeCallbackFunction', { runtime: lambda.Runtime.NODEJS_20_X, handler: 'authorizeCallback.handler', code: lambda.Code.fromAsset('lambda/authorizeCallback'), }); // Grant Lambda functions access to DynamoDB tables configTable.grantReadWriteData(initiateLaunchLambda); authDataTable.grantReadWriteData(initiateLaunchLambda); configTable.grantReadWriteData(authorizeCallbackLambda); authDataTable.grantReadWriteData(authorizeCallbackLambda); // API Gateway Setup const api = new apigateway.RestApi(this, 'LtiLaunchApi', { restApiName: 'LTI Launch Service', description: 'Handles LTI 1.3 launches without relying on cookies.', }); // Integrate Lambda functions with API Gateway const initiateIntegration = new apigateway.LambdaIntegration(initiateLaunchLambda); const authorizeIntegration = new apigateway.LambdaIntegration(authorizeCallbackLambda); // Define API Gateway resources and methods const ltiResource = api.root.addResource('lti'); const initiateResource = ltiResource.addResource('initiate'); initiateResource.addMethod('POST', initiateIntegration); // Initiate Launch Endpoint const authorizeResource = ltiResource.addResource('authorize'); authorizeResource.addMethod('POST', authorizeIntegration); // Authorize Callback Endpoint } }
Lambda Function Workflow
Initiate Launch Lambda
Validation: Ensures that the incoming request contains valid LTI launch parameters.
State Generation: Creates unique state and nonce values to prevent CSRF attacks.
Storage: Saves the state parameters in DynamoDB for later validation.
OIDC Authorization: Generates an OIDC authorization URL based on config in DynamoDB
Response Handling:
If the LMS supports the shared storage mechanism (lti_storage_target is present), it responds with an HTML page containing JavaScript to interact with the shared storage iframe and redirect the user to start the OIDC authentication flow. Note the cookies are still set here as a fallback option for later.
If not, it falls back to setting cookies to achieve the same result.
Illustrative Code Snippet:
export const handler: APIGatewayProxyHandler = async (event) => { // Parse the POST body const body = event.body ? JSON.parse(event.body) : {}; // Validate the incoming LTI launch request if (!validateLtiRequest(body)) { throw new Error('Invalid LTI launch request.') } // Generate unique state and nonce parameters const { state, nonce } = generateStateParameters(); // Store the state parameters securely (e.g., in DynamoDB) await storeState(state, nonce); // Generate the OIDC authorization URL const authorizationUrl = generateAuthorizationUrl({ state, nonce, redirectUri: `${getBaseUrlFromEvent(event)}/lti/authorize`, clientId: body.client_id, loginHint: body.login_hint, ltiMessageHint: body.lti_message_hint, ltiStorageTarget: body.lti_storage_target, }); // Check if the LMS supports shared storage if (body.lti_storage_target) { // Respond with an HTML page that uses postMessage to store state const scriptNonce = generateScriptNonce(); const platformOrigin = getPlatformOidcOrigin(); const htmlResponse = generateHtmlResponse( scriptNonce, state, nonce, platformOrigin, authorizationUrl, storageTarget: body.lti_storage_target ); return { statusCode: 200, headers: { 'content-type': 'text/html', ...generateCspHeaders({scriptSrc: `'nonce-${scriptNonce}'`}), "Set-Cookie": [ `state=${state}; HttpOnly; Secure; SameSite=Strict`, `nonce=${nonce}; HttpOnly; Secure; SameSite=Strict` ] }, body: htmlResponse, }; } else { // Fallback to setting cookies and redirecting to the authorization URL return { statusCode: 302, headers: { Location: authorizationUrl, "Set-Cookie": [ `state=${state}; HttpOnly; Secure; SameSite=Strict`, `nonce=${nonce}; HttpOnly; Secure; SameSite=Strict` ], }, body: '', }; } }; const generateHtmlResponse = ( scriptNonce: string, state: string, nonce: string, platformOrigin: string, authorizationUrl: string, targetFrameName: string = "_parent" ): string => { const appName = process.env.APP_NAME; const safeFrameName = JSON.stringify(targetFrameName); return `<html><body><script nonce="${scriptNonce}"> (function() { const parent = window.parent || window.opener; const targetFrame = ${safeFrameName} === "_parent" ? parent : parent.frames[${safeFrameName}] try { // Post put_data messages to platform (store state and nonce values) targetFrame.postMessage({ subject: "lti.put_data", message_id: '${randomUUID()}', key: '${appName}_state_${state}', value: '${state}', }, '${platformOrigin}'); targetFrame.postMessage({ subject: "lti.put_data", message_id: '${randomUUID()}', key: '${appName}_nonce_${nonce}', value: '${nonce}', }, '${platformOrigin}'); // Redirect to authorization URL document.location = '${authorizationUrl}'; } catch (error) { console.error('Failed to post message', error); } })(); </script></body></html>`; };
Authorize Callback Lambda
This function processes the authorization callback by:
State Retrieval: Fetching the stored state and nonce from DynamoDB using the state parameter received in the request.
Validation: Ensuring that the state and nonce values all match to confirm the authenticity of the request.
Session Creation: Generating a user session for our tool and saving to DynamoDB for later retrieval.
Redirection: Redirecting the user to the LTI tool with a one-time code in the query parameters for retrieving the session.
Response Handling:
If a cookie is found in the incoming request, then we assume that the flow was initiated using the historical cookie approach, and we validate the state and nonce values in the cookie against those in the DB before redirecting with a 302
If not, we use the shared storage mechanism again and respond with an HTML page containing JavaScript to perform the validation/redirect logic.
Illustrative Code Snippet:
export const handler: APIGatewayProxyHandler = async (event) => { // Parse the POST body const body = event.body ? JSON.parse(event.body) : {}; // Retrieve and validate login state from Dynamo const dbLoginState = await getLoginStateByStateId(body.state); // Get deployment configuration from Dynamo const config = await getDeploymentConfig(); let verifiedTokenSet; try { verifiedTokenSet = await verifyIdToken( config.oidcIssuerMetaData, body.id_token) } catch (error) { throw new Error('Error while verifying ID token.') } const claims = verifiedTokenSet.claims; // Extract user information from claims and validate as necessary const sub = claims["sub"] const targetLinkUri = claims['https://purl.imsglobal.org/spec/lti/claim/target_link_uri']; const email = claims["email"]; if (!email) { throw new Error("Expected email claim"); } // Generate and save session const newSession = await generateSession( dbLoginState.deploymentId, sub, email, body.id_token ); await saveSession(newSession); // Redirect to the LTI tool with one-time code const templatedTargetLinkUri = `${targetLinkUri}?otc=${newSession.oneTimeCode}`; const { state: cookieState, nonce: cookieNonce } = parseInitiateLoginCookies(event.headers?.['cookie']) || {}; const cookieFound = !!cookieState && !!cookieNonce; // If cookie is present that proceed as if the flow started with the cookie approach if (cookieFound) { if ((dbLoginState.state !== cookieState) || dbLoginState.nonce !== cookieNonce) { throw new Error("State mismatch between cookie and login state"); } return { statusCode: 302, headers: { "content-type": "text/html", "location": templatedTargetLinkUri, }, body: "", } } else { // Use the postMessage API for when third party cookies are blocked const scriptNonce = generateScriptNonce(); const platformOrigin = getPlatformOidcOrigin(); return { statusCode: 200, headers: { "content-type": "text/html", ...generateCspHeaders({scriptSrc: `'nonce-${scriptNonce}'`}), }, body: generateHtmlResponse( scriptNonce, dbLoginState.state, dbLoginState.nonce, platformOrigin, templatedTargetLinkUri, dbLoginState.context.ltiStorageTarget ) } } }; const generateHtmlResponse = ( scriptNonce: string, state: string, nonce: string, platformOrigin: string, targetUri: string, targetFrameName: string = "_parent" ): string => { const appName = process.env.APP_NAME; const safeFrameName = JSON.stringify(targetFrameName); const messageIdState = uuidv4(); const messageIdNonce = uuidv4(); return ` <html> <body> <script nonce="${scriptNonce}"> (function() { const getStateMessageId = '${messageIdState}'; const getNonceMessageId = '${messageIdNonce}'; // Helper function to send get_data messages function sendGetDataMessage(messageId, key, frame) { const message = { subject: "lti.get_data", message_id: messageId, key: key, }; frame.postMessage(message, "${platformOrigin}"); } // Helper function to handle message responses function handleGetDataResponse(event, expectedValue, messageId, type) { if (typeof event.data !== "object" || event.origin !== "${platformOrigin}") { return false; } if (event.data.message_id === messageId && event.data.subject === "lti.get_data.response") { if (event.data.value !== expectedValue) { console.error(\`\${type} doesn't match: \`, event.data.value); return false; } return true; } return false; } let stateValid = false; let nonceValid = false; // Event listener for get_data responses window.addEventListener("message", function(event) { if (!stateValid) { stateValid = handleGetDataResponse(event, "${state}", getStateMessageId, "State"); } if (!nonceValid) { nonceValid = handleGetDataResponse(event, "${nonce}", getNonceMessageId, "Nonce"); } // Check if both state and nonce are valid before redirecting if (stateValid && nonceValid) { document.location = '${targetUri}'; } }, false); const parentWindow = window.parent || window.opener; const targetFrame = ${safeFrameName} === "_parent" ? parentWindow : parentWindow.frames[${safeFrameName}]; // Send get_data messages for state and nonce sendGetDataMessage(getStateMessageId, "${appName}_state_${state}", targetFrame); sendGetDataMessage(getNonceMessageId, "${appName}_nonce_${nonce}", targetFrame); })(); </script> </body> </html> `; };
Key takeaways
Modular Infrastructure with AWS CDK
Using CDK allowed us to define our infrastructure in a modular and reusable manner. We ended up refactoring the patterns into a CDK construct which we can easily reuse across multiple applications and clients. This approach also significantly simplifies migrating from LTI 1.1 to 1.3.
Careful Handling of Dynamic Scripts
Generating dynamic HTML and JavaScript via string substitution inside a lambda was funky. Debugging required meticulous attention to ensure variables were correctly and safely injected, and the resulting scripts functioned as intended.
Secure Session Retrieval Without Cookies on the Frontend
After implementing a cookie-less approach for the LTI 1.3 launch and redirecting to the LTI tool, we ironically faced a new challenge: retrieving the session securely without relying on cookies. We explored several ideas, such as triggering an additional OAuth flow after completing the LTI process, redirecting to our app’s frontend, and using an identity provider like AWS Cognito. However, this approach encountered further issues in a third-party iframe (such as click-jacking) and proved unfeasible. Ultimately, we devised a solution that issues a short-lived, one-time code to allow the frontend to securely retrieve session details. To make this work seamlessly with our React-based frontend, we built a custom React hook to safely manage the code exchange process.
Wrapping Up
We cracked the cookie problem (pun intended). While we continue to iterate and improve upon our solution, it’s already in action with one of our international clients, where it’s driving their migration from LTI 1.1 to 1.3. We’re also using this solution as the foundation for new products, like our Course Publishing Checklist Tool, and look forward to integrating it into our flagship product, Grade Matter, bringing the latest LTI standards to the forefront of EdTech.
Written by Michael Tharratt
Software Developer at All the Ducks