Access Control

Delegate Methods

The delegate system can be used to implement authorization logic ranging from simple to complex. The delegates.rb.sample file contains two delegate methods—pre_authorize() and authorize()—along with documentation of how they work. By default, they both return true, authorizing all requests.

pre_authorize() is a new method in version 5.0. It is called upon all requests to all public endpoints early in the request cycle, before any image has been accessed. This means that it cannot be used to implement authorization policy that depends on information about the source image. However, because it is invoked early, it should be used to implement all other authorization logic.

authorize() is invoked later in the request cycle, after pre_authorize() has returned true, and once information about the source image is available. When authorization policy does not depend on this information, this method should just return true.

authorize() is only invoked for image requests—not information requests, or any other type of request.

Implementations should assume that the underlying source image exists. The existence check will happen separately.

Examples

Allow only requests for half-scale images or smaller

class CustomDelegate
  def pre_authorize(options = {})
    true
  end

  def authorize(options = {})
    scale = context['operations'].find{ |op| op['class'] == 'Scale' }
    if scale
      max_scale = 0.5
      return scale['width'] <= full_size['width'] * max_scale &&
          scale['height'] <= full_size['height'] * max_scale
    end
    false
  end
end

Allow only requests for identifiers matching a certain pattern

class CustomDelegate
  def pre_authorize(options = {})
    identifier = context['identifier']
    # Allow only identifiers that don't include "_restricted"
    return !identifier.include?('_restricted')
    # Allow only identifiers that start with "_public"
    return identifier.start_with?('public_')
    # Allow only identifiers matching a regex
    return identifier.match(/^image[5-9][0-9]/)
  end

  def authorize(options = {})
    true
  end
end

Allow only JPEG output

class CustomDelegate
  def pre_authorize(options = {})
    context['output_format'] == 'image/jpeg'
  end

  def authorize(options = {})
    true
  end
end

Allow only certain user agents

This is not foolproof, as user agents can be faked.

class CustomDelegate
  def pre_authorize(options = {})
    headers = context['request_headers']
    agent = headers.find{ |h, v| h.downcase == 'user-agent' }
    agent&.start_with?('MyAllowedUserAgent/')
  end

  def authorize(options = {})
    true
  end
end

Allow only requests that supply a valid token in a header

class CustomDelegate
  def pre_authorize(options = {})
    headers['X-MyToken'] == ... # write code to authorize the token
  end

  def authorize(options = {})
    true
  end
end

Restrict a region of an image

In this example, requests for images containing any part of the bottom right quadrant of the source image will be denied.

(Also see redaction.)

class CustomDelegate
  def pre_authorize(options = {})
    true
  end

  def authorize(options = {})
    crop = context['operations'].find{ |op| op['class'] == 'Crop' }
    if crop
      max_x = full_size['width'] / 2.0
      max_y = full_size['height'] / 2.0
      return !(crop['x'] + crop['width'] > max_x and
          crop['y'] + crop['height'] > max_y)
    end
    false
  end
end

Restrict with HTTP Basic authentication

With HTTP Basic authentication, the server sends an HTTP 401 (Unauthorized) status code along with a WWW-Authenticate: Basic header, which tells the client that subsequent requests must include an Authorization header containing encoded credentials. (Web browsers will normally prompt for these and remember them for a time.)

HTTP Basic should only be used over SSL/TLS.

require 'base64'

class CustomDelegate
  def pre_authorize(options = {})
    header = context['request_headers']
                 .select{ |name, value| name.downcase == 'authorization' }
                 .values.first
    return true if authenticate_basic(header)
    return {
        'status_code' => 401,
        'challenge' => 'Basic realm="MyRealm" charset="UTF-8"'
    }
  end

  def authorize(options = {})
    true
  end

  def authenticate_basic(header)
    if header&.start_with?('Basic ')
      encoded = header[6..header.length - 1]
      creds = Base64.decode64(encoded).split(':')
      if creds.length > 1
        return (creds[0] == 'my_user' and creds[1] == 'my_secret')
      end
    end
    false
  end
end

Redirect to a reduced-quality version

See Tiered Access.

Redirect to another URL

class CustomDelegate
  def pre_authorize(options = {})
    {
      'status_code' => 303, # "See Other"
      'location' => 'http://example.org/some-other-url'
    }
  end

  def authorize(options = {})
    true
  end
end

Add an overlay for some requests

See Overlays.

IIIF Authentication API

The IIIF Authentication API 1.0 describes general patterns of access-restricting IIIF resources, including images, in a standard way. The authorize() delegate method can be used in ways that conform to this API's notions of "all or nothing" and "tiered" access.

For both of these:

  1. extra_iiifx_information_response_keys() must return the authentication services used by the API, which you must provide.
  2. The authorize() method must accept bearer tokens from the client, and return a status of 200 (or true) for authorized requests; 302 for redirects; and 401 for unauthorized requests.

All or Nothing Access

All or Nothing Access grants access to either the full-quality image, or nothing.

Example

class CustomDelegate
  def authorize(options = {})
    header = context['request_headers']
                 .select{ |name, value| name.downcase == 'authorization' }
                 .values.first
    if header&.start_with?('Bearer ')
      token = header[7..header.length - 1]
      # Authorize the token and return true if it's valid.
    end
    return {
        'status_code' => 401,
        'challenge' => 'Bearer charset="UTF-8"'
    }
  end
end

Tiered Access

Tiered Access can be used to redirect to any of a number of "virtual" reduced-quality versions ("tiers") of an image based on authorization status.

The IIIF Authorization API is indifferent about how a quality tier system should work. Cantaloupe uses a resolution-based system that limits the maximum available resolution. For example, a fully-authorized user might be able to access the full resolution of an image, whereas an unauthorized user might be able to access only a half-resolution version.

This works transparently with zooming viewer clients, limiting the maximum available zoom level.

URI identifiers of scale-constrained images are meta-identifiers which, by default, have a suffix in the format ;numerator:denominator. So, for example, if the client requests image.tif/full/max/0/default.jpg, but is only authorized to access a half-resolution version, it is redirected to image.tif;1:2/full/max/0/default.jpg, from which it is allowed to retrieve the full size of a dynamically half-sized version of image.tif.

Example

class CustomDelegate

  # The maximum scale allowed to unauthorized clients.
  MAX_UNAUTHORIZED_R = Rational(1, 2)

  def authorize(options = {})
    header = context['request_headers']
                  .select{ |name, value| name.downcase == 'authorization' }
                  .values.first
    if header&.start_with?('Bearer ')
      token = header[7..header.length]
      if valid?(token)
        # Get the current scale constraint fraction, which ultimately comes
        # from the identifier URI path component, which is conveniently
        # made available in the delegate context.
        # This is a two-element array of numerator and denominator integers.
        scale_constraint = context['scale_constraint']

        # If it exists, convert it to a Rational (Ruby's fraction class).
        # Otherwise, use 1/1.
        scale_constraint_r = scale_constraint ?
            Rational(*scale_constraint) : Rational(1)

        # If the requested scale constraint is larger than authorized,
        # redirect to the authorized scale constraint.
        if scale_constraint_r > MAX_UNAUTHORIZED_R
          return {
              'status_code' => 302,
              'scale_numerator' => MAX_UNAUTHORIZED_R.numerator,
              'scale_denominator' => MAX_UNAUTHORIZED_R.denominator
          }
        else
          return true
        end
      end
    end
    return {
        'status_code' => 401,
        'challenge' => 'Bearer charset="UTF-8"'
    }
  end

  def valid?(token)
    # check whether the token is valid and return true or false
  end
end