Developer SDK

Ruby examples

Install the SDK · View source on GitHub ↗

integration/server/allocator.rb

# frozen_string_literal: true
# SERVER ONLY: ruby allocator.rb user:<authenticated-service-user-id> | --self-test
# The authenticated backend supplies the service key internally, never as a raw
# request field or a UR client ID. Set URNETWORK_ROOT_JWT, URNETWORK_CLIENT_MAP
# (absolute path under an existing service-owned directory) and optional URNETWORK_API_URL.
# An interrupted allocator may leave .lock; remove it only after verifying it is stale.
require 'base64'
require 'json'
require 'net/http'
require 'uri'
require 'tempfile'
require 'tmpdir'
require 'fileutils'

module Allocator
  LIMIT = 1 << 20
  USER = /\Auser:[A-Za-z0-9][A-Za-z0-9_.:@-]{0,122}\z/
  ID = /\A[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/
  module_function

  def service_user(args)
    raise 'expected one user:<service-user-id>' unless args.length == 1 && USER.match?(args.first)
    args.first
  end

  def endpoint(base)
    u = URI(base)
    unless u.host && !u.userinfo && ['', '/'].include?(u.path) && !u.query && !u.fragment &&
           (u.scheme == 'https' || (u.scheme == 'http' && ['localhost', '127.0.0.1', '::1'].include?(u.hostname)))
      raise 'API must be an HTTPS origin; HTTP is allowed only for explicit loopback mocks'
    end
    URI(base.sub(%r{/$}, '') + '/network/auth-client')
  end

  def request_for(user, client = nil)
    service_user([user])
    body = {'description' => "service #{user}", 'device_spec' => 'urnetwork-examples/ruby-server'}
    unless client.nil?
      raise 'invalid mapped client' unless client.is_a?(String) && ID.match?(client)
      body['client_id'] = client
    end
    body
  end

  def parse_response(raw, expected = nil)
    obj = JSON.parse(raw)
    raise 'API failure' unless obj.is_a?(Hash) && obj['error'].nil?
    client, jwt = obj.values_at('client_id', 'by_client_jwt')
    raise 'invalid API result' unless client.is_a?(String) && ID.match?(client) && jwt.is_a?(String)
    parts = jwt.split('.', -1)
    raise 'invalid client JWT' unless parts.length == 3 && parts.none?(&:empty?) && /\A[A-Za-z0-9_-]+\z/.match?(parts[1])
    claims = JSON.parse(Base64.urlsafe_decode64(parts[1]))
    unless claims.is_a?(Hash) && claims['client_id'] == client && (expected.nil? || expected == client)
      raise 'scoped client identity mismatch'
    end
    # Consistency check only; the API authenticates the root and signs the client JWT.
    {'client_id' => client, 'by_client_jwt' => jwt}
  end

  def load_map(file)
    raise 'mapping cannot be a symlink' if File.symlink?(file)
    return {'version' => 1, 'clients' => {}} unless File.exist?(file)
    stat = File.stat(file)
    raise 'mapping must be private (0600)' unless stat.file? && stat.size <= LIMIT && (stat.mode & 0o077).zero?
    map = JSON.parse(File.read(file))
    raise 'invalid mapping' unless map.is_a?(Hash) && map['version'] == 1 && map['clients'].is_a?(Hash)
    seen = {}
    map['clients'].each do |user, client|
      service_user([user])
      raise 'invalid mapped client' unless client.is_a?(String) && ID.match?(client) && !seen[client]
      seen[client] = true
    end
    map
  end

  def save_map(file, map)
    Tempfile.create(['clients-', '.json'], File.dirname(file)) do |out|
      out.chmod(0o600)
      out.write(JSON.generate(map))
      out.flush
      out.fsync
      File.rename(out.path, file)
    end
  end

  def allocate(user, file)
    raise 'mapping needs an absolute path and existing parent' unless file.start_with?('/') && File.directory?(File.dirname(file))
    lock = file + '.lock'
    Dir.mkdir(lock, 0o700)
    begin
      map = load_map(file)
      old = map['clients'][user]
      result = parse_response(yield(request_for(user, old)), old)
      if old.nil?
        raise 'client assigned to another user' if map['clients'].value?(result['client_id'])
        map['clients'][user] = result['client_id']
        save_map(file, map)
      end
      result
    ensure
      Dir.rmdir(lock)
    end
  end

  def post(url, root, body)
    raise 'set backend root JWT' if root.nil? || root.empty? || /\s/.match?(root)
    request = Net::HTTP::Post.new(url)
    request['Authorization'] = 'Bearer ' + root
    request['Content-Type'] = 'application/json'
    request.body = JSON.generate(body)
    result = +''
    Net::HTTP.start(url.hostname, url.port, use_ssl: url.scheme == 'https', open_timeout: 15, read_timeout: 15, write_timeout: 15) do |http|
      http.request(request) do |response|
        raise 'provisioning HTTP failure' unless response.is_a?(Net::HTTPSuccess)
        response.read_body do |part|
          raise 'response too large' if result.bytesize + part.bytesize > LIMIT
          result << part
        end
      end
    end
    result
  end

  def self_test
    client = '11111111-1111-1111-1111-111111111111'
    jwt = 'e30.' + Base64.urlsafe_encode64(JSON.generate({'client_id' => client}), padding: false) + '.test'
    response = JSON.generate({'client_id' => client, 'by_client_jwt' => jwt})
    calls = []
    mock = ->(body) { calls << body; response }
    Dir.mktmpdir('ur-allocator-') do |directory|
      file = File.join(directory, 'clients.json')
      raise 'new allocation failed' unless allocate('user:alice', file, &mock)['client_id'] == client
      raise 'reissue failed' unless allocate('user:alice', file, &mock)['by_client_jwt'] == jwt
      raise 'wrong request shape' if calls[0].key?('client_id') || calls.any? { |c| c.key?('source_client_id') }
      raise 'mapped ID missing' unless calls[1]['client_id'] == client
      raise 'round-trip failed' unless load_map(file)['clients'] == {'user:alice' => client}
      raise 'endpoint failed' unless endpoint('http://127.0.0.1:1234').path == '/network/auth-client'
      [-> { service_user([client]) }, -> { service_user(['user:a', '--client-id', client]) },
       -> { service_user(['user:../a']) }, -> { endpoint('http://example.com') }, -> { endpoint('https://example.com/path') },
       -> { parse_response('{"error":{}}') }, -> { parse_response(response, '22222222-2222-2222-2222-222222222222') }].each do |invalid|
        rejected = false
        begin
          invalid.call
        rescue StandardError
          rejected = true
        end
        raise 'invalid input accepted' unless rejected
      end
    end
    puts 'allocator self-test passed'
  end
end

begin
  if ARGV == ['--self-test']
    Allocator.self_test
  else
    user = Allocator.service_user(ARGV)
    url = Allocator.endpoint(ENV.fetch('URNETWORK_API_URL', 'https://api.bringyour.com'))
    root, file = ENV.fetch('URNETWORK_ROOT_JWT'), ENV.fetch('URNETWORK_CLIENT_MAP')
    puts JSON.generate(Allocator.allocate(user, file) { |body| Allocator.post(url, root, body) })
  end
rescue StandardError
  warn 'allocator failed: check service key, private mapping and backend API configuration'
  exit 1
end