Ltix.OAuth.Client (Ltix v0.2.0)

Copy Markdown View Source

Authenticated OAuth session for LTI Advantage service calls.

Holds an Ltix.OAuth.AccessToken with the scopes it was granted, the registration and deployment it was requested for, and the service endpoints it calls, and provides explicit refresh. Pass this struct to service functions like Ltix.MembershipsService.list_members/2.

registration is the struct you authenticated with, which can be your own struct implementing Ltix.Registerable.

Refreshing

client = Ltix.OAuth.Client.refresh!(client)

Reusing tokens across contexts

A token is valid across contexts (courses, launches) on the same registration. A platform may also limit it to the deployment it was requested for. Reuse a cached token with different endpoints.

{:ok, client_b} = Ltix.OAuth.Client.with_endpoints(client, %{
  Ltix.MembershipsService => course_b_endpoint
})

Or cache client.token and build a client from it later.

{:ok, client} = Ltix.OAuth.Client.from_access_token(cached_token,
  registration: registration,
  deployment: deployment,
  endpoints: %{Ltix.MembershipsService => endpoint}
)

Summary

Functions

Check whether the client's token has expired, the same way as Ltix.OAuth.AccessToken.expired?/1.

Force a refresh regardless of expiry. Useful for testing or for refreshing before making a batch of calls.

Same as force_refresh/1 but raises on error.

Build a client from a cached AccessToken.

Check whether the client's token was granted a specific scope.

If expired, re-acquire the token using the stored registration and endpoints. Otherwise, return the client unchanged.

Same as refresh/1 but raises on error.

Send an authenticated request to a platform service.

Send an authenticated GET request and follow the rel="next" links of the responses.

Require any one of the given scopes.

Require a specific scope, returning an error if not granted.

Require a token that hasn't expired, returning Ltix.Errors.Security.AccessTokenExpired if it has.

Replace the endpoints of an existing client.

Types

t()

@type t() :: %Ltix.OAuth.Client{
  deployment: Ltix.Deployable.t() | String.t() | nil,
  endpoints: %{required(module()) => term()},
  local_network_access: :none | :local | :loopback | nil,
  registration: Ltix.Registerable.t(),
  req_options: keyword(),
  storage_adapter: module() | nil,
  storage_adapter_options: keyword(),
  token: Ltix.OAuth.AccessToken.t()
}

Functions

expired?(client)

@spec expired?(t()) :: boolean()

Check whether the client's token has expired, the same way as Ltix.OAuth.AccessToken.expired?/1.

Examples

iex> token = %Ltix.OAuth.AccessToken{expires_at: DateTime.add(DateTime.utc_now(), 3600)}
iex> Ltix.OAuth.Client.expired?(%Ltix.OAuth.Client{token: token})
false

force_refresh(client)

@spec force_refresh(t()) :: {:ok, t()} | {:error, Exception.t()}

Force a refresh regardless of expiry. Useful for testing or for refreshing before making a batch of calls.

force_refresh!(client)

@spec force_refresh!(t()) :: t()

Same as force_refresh/1 but raises on error.

from_access_token(token, opts)

@spec from_access_token(Ltix.OAuth.AccessToken.t(), keyword()) ::
  {:ok, t()} | {:error, Exception.t()}

Build a client from a cached AccessToken.

The token may lack some of the scopes the endpoints need.

Options

  • :registration (term/0) - Required. Any struct implementing Ltix.Registerable.

  • :endpoints (map/0) - Required. Map of service modules to endpoint structs.

  • :deployment (term/0) - The deployment the token is for, as a struct implementing Ltix.Deployable or a deployment ID. Token requests include it.

  • :req_options (keyword/0) - Options passed through to Req.request/2. The default value is [].

  • :local_network_access (nil | :none | :local | :loopback) - Addresses platform requests may connect to besides public ones. :local adds private network addresses, and :loopback adds those and this machine. Defaults to config :ltix, :local_network_access, then :none.

  • :storage_adapter (module/0) - Module implementing Ltix.StorageAdapter, which signs token requests. Defaults to the application configuration.

  • :storage_adapter_options (keyword/0) - Options passed to the storage adapter's signing callbacks. The default value is [].

  • :require_all_scopes (boolean/0) - Return a ScopeMismatch error unless every scope the endpoints need was granted. The default value is false.

from_access_token!(token, opts)

@spec from_access_token!(Ltix.OAuth.AccessToken.t(), keyword()) :: t()

Same as from_access_token/2 but raises on error.

has_scope?(client, scope)

@spec has_scope?(t(), String.t()) :: boolean()

Check whether the client's token was granted a specific scope.

Examples

iex> token = %Ltix.OAuth.AccessToken{granted_scopes: MapSet.new(["scope:read"])}
iex> client = %Ltix.OAuth.Client{token: token}
iex> Ltix.OAuth.Client.has_scope?(client, "scope:read")
true
iex> Ltix.OAuth.Client.has_scope?(client, "scope:write")
false

refresh(client)

@spec refresh(t()) :: {:ok, t()} | {:error, Exception.t()}

If expired, re-acquire the token using the stored registration and endpoints. Otherwise, return the client unchanged.

Re-derives requested scopes from endpoints via each service's scopes/1 callback, so a transient partial grant does not become permanent.

refresh!(client)

@spec refresh!(t()) :: t()

Same as refresh/1 but raises on error.

request(client, service, opts)

@spec request(t(), module(), keyword()) ::
  {:ok, Req.Response.t()} | {:error, Exception.t()}

Send an authenticated request to a platform service.

Use this to call a platform from your own Ltix.AdvantageService. opts are Req request options such as :url, :method, :headers, :params, and :json. The request has the client's access token as its bearer token, and it gets the same checks as every request Ltix sends, including HTTPS and the client's :local_network_access setting.

Returns {:ok, response} for a successful status. A 401 or 403 returns Ltix.Errors.Security.AccessDenied, and any other status returns Ltix.Errors.Unknown.TransportError.

In tests, stub the request with Req.Test.stub(service, ...).

Examples

{:ok, %Req.Response{body: body}} =
  Ltix.OAuth.Client.request(client, MyApp.AttendanceService,
    url: endpoint.attendance_url,
    headers: [{"accept", "application/json"}]
  )

request_pages(client, service, opts)

@spec request_pages(t(), module(), keyword()) ::
  {:ok, Enumerable.t()} | {:error, Exception.t()}

Send an authenticated GET request and follow the rel="next" links of the responses.

Takes the options of request/3. :params apply to the first page only.

Fetches the first page before returning. Returns {:ok, stream} with {:ok, body} for each page, or {:error, error} if the first page fails. Later pages are fetched as the stream is consumed. A later failure emits one {:error, error} and ends the stream.

Examples

{:ok, pages} =
  Ltix.OAuth.Client.request_pages(client, MyApp.AttendanceService,
    url: endpoint.attendance_url,
    params: [limit: 50]
  )

Enum.flat_map(pages, fn {:ok, body} -> body["records"] end)

require_any_scope(client, scopes)

@spec require_any_scope(t(), [String.t()]) :: :ok | {:error, Exception.t()}

Require any one of the given scopes.

Returns :ok if at least one scope from the list was granted.

require_scope(client, scope)

@spec require_scope(t(), String.t()) :: :ok | {:error, Exception.t()}

Require a specific scope, returning an error if not granted.

require_unexpired(client)

@spec require_unexpired(t()) :: :ok | {:error, Exception.t()}

Require a token that hasn't expired, returning Ltix.Errors.Security.AccessTokenExpired if it has.

Examples

iex> token = %Ltix.OAuth.AccessToken{expires_at: DateTime.add(DateTime.utc_now(), 3600)}
iex> Ltix.OAuth.Client.require_unexpired(%Ltix.OAuth.Client{token: token})
:ok

with_endpoints(client, endpoints, opts \\ [])

@spec with_endpoints(t(), %{required(module()) => term()}, keyword()) ::
  {:ok, t()} | {:error, Exception.t()}

Replace the endpoints of an existing client.

The token remains the same, so it may lack some of the scopes the new endpoints need.

Options

  • :require_all_scopes (boolean/0) - Return a ScopeMismatch error unless every scope the endpoints need was granted. The default value is false.

with_endpoints!(client, endpoints, opts \\ [])

@spec with_endpoints!(t(), %{required(module()) => term()}, keyword()) :: t()

Same as with_endpoints/3 but raises on error.