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

Next
Next

Welcome to the National Student Ombudsman