Skip to content

Simon-Initiative/lightbulb

Repository files navigation

💡 lightbulb

A library for building LTI 1.3 tools in Gleam

Package Version Hex Docs

For API changes across versions, see CHANGELOG.md.

Installation

gleam add lightbulb@2

Usage

The example below shows how to use the library in a Gleam Wisp application. It includes two endpoints: one for OIDC login and another for validating the launch request. For a complete example, see the lti-example-tool repository.

import gleam/dict.{type Dict}
import gleam/http
import gleam/http/cookie
import gleam/http/request
import gleam/http/response
import gleam/list
import gleam/option.{Some}
import gleam/string
import lightbulb/errors
import lightbulb/providers/data_provider.{type DataProvider}
import lightbulb/tool
import wisp.{type Request, type Response, redirect}

pub fn oidc_login(req: Request, data_provider: DataProvider) -> Response {
  // Get all query and body parameters from the request.
  use params <- all_params(req)

  // Build the OIDC login state and URL response.
  case tool.oidc_login(data_provider, params) {
    Ok(#(state, redirect_url)) -> {
      use <- set_cookie(
        "state",
        state,
        cookie.Attributes(
          ..cookie.defaults(http.Https),
          same_site: Some(cookie.None),
          max_age: option.Some(60 * 60 * 24),
        ),
      )

      redirect(to: redirect_url)
    }
    Error(error) ->
      wisp.internal_server_error()
      |> wisp.string_body(
        "OIDC login failed: " <> errors.core_error_to_string(error),
      )
  }
}

pub fn validate_launch(req: Request, data_provider: DataProvider) -> Response {
  // Get all query and body parameters from the request.
  use params <- all_params(req)

  // Get the state cookie that was set during the OIDC login.
  use state <- require_cookie(req, "state", or_else: fn() {
    wisp.bad_request()
    |> wisp.string_body("Required 'state' cookie not found")
  })

  // Validate the launch request using the parameters and state.
  case tool.validate_launch(data_provider, params, state) {
    Ok(claims) -> {
      wisp.ok()
      |> wisp.string_body("Launch successful! " <> string.inspect(claims))
    }
    Error(e) -> {
      wisp.bad_request()
      |> wisp.string_body(
        "Invalid launch: " <> errors.core_error_to_string(e),
      )
    }
  }
}

/// Helper functions

fn all_params(
  req: Request,
  cb: fn(Dict(String, String)) -> Response,
) -> Response {
  use formdata <- wisp.require_form(req)

  // Combine query and body parameters into a single dictionary. Body parameters
  // take precedence over query parameters.
  let params =
    wisp.get_query(req)
    |> dict.from_list()
    |> dict.merge(dict.from_list(formdata.values))

  cb(params)
}

fn set_cookie(
  name: String,
  value: String,
  attributes: cookie.Attributes,
  cb: fn() -> Response,
) -> Response {
  cb()
  |> response.set_cookie(name, value, attributes)
}

fn require_cookie(
  req: Request,
  cookie_name: String,
  or_else bail: fn() -> Response,
  cb cb: fn(String) -> Response,
) -> Response {
  case get_cookie(req, cookie_name) {
    Ok(cookie) -> cb(cookie)
    Error(_) -> bail()
  }
}

fn get_cookie(req: Request, name name: String) -> Result(String, Nil) {
  req
  |> request.get_cookies
  |> list.key_find(name)
}

Typical Integration Flow

  1. Implement required providers:
  2. Handle OIDC login with tool.oidc_login.
  3. Validate launch requests with tool.validate_launch.
  4. Dispatch by LTI message type and feature:
    • Resource link launches: AGS/NRPS flows as needed.
    • Deep-link launches: decode settings and return a signed deep-link response.
  5. For service calls, fetch OAuth tokens via services/access_token.

AGS (Assignments and Grades)

AGS module includes full AGS line-item CRUD, results retrieval, scope helpers, and pagination metadata support.

NRPS (Names and Roles)

NRPS module includes NRPS APIs for claim decode, scope checks, filtered membership fetches, and pagination links.

Deep Linking

Deep Linking module includes support for decoding deep-link launch claims, building signed response JWTs, and constructing form-post payloads for the response.

Use response JWT profiles when you need LMS-specific deep-link JWT semantics:

  • deep_linking.Standard: default standards-oriented behavior (same as build_response_jwt).
  • deep_linking.Canvas: Canvas-compatible identity claims.
  • deep_linking.Custom(fn(claims, context) { ... }): custom claim transforms.

Example profile selection by issuer:

import gleam/string
import lightbulb/deep_linking

fn choose_profile(issuer: String) -> deep_linking.ResponseJwtProfile {
  case string.contains(string.lowercase(issuer), "instructure.com") {
    True -> deep_linking.Canvas
    False -> deep_linking.Standard
  }
}

Example JWT build with profile:

deep_linking.build_response_jwt_with_profile(
  claims,
  settings,
  items,
  deep_linking.default_response_options(),
  active_jwk,
  choose_profile(platform_issuer),
)

Migration note:

  • Existing build_response_jwt callers require no changes; the function retains prior behavior.
  • Adopt build_response_jwt_with_profile only when LMS-specific shaping is required.

OAuth Service Tokens

OAuth Service Tokens module provides utilities for fetching and caching OAuth access tokens for LTI services (AGS, NRPS, etc.).

Data Providers

lightbulb requires two provider interfaces:

  • DataProvider for nonce, launch context, registration/deployment, and JWK persistence used by OIDC login and launch validation.
  • HttpProvider for outbound HTTP transport used by service modules (AGS, NRPS, OAuth token requests).

See module documentation:

Development

gleam test  # Run the tests

About

A library for building LTI 1.3 tools in Gleam

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages