Class: Basecamp::Oauth::Resource

Inherits:
Object
  • Object
show all
Defined in:
lib/basecamp/oauth/resource.rb

Overview

Fetches RFC 9728 protected-resource metadata (hop 1 of resource-first discovery) and binds the returned resource to the requested origin.

Instance Method Summary collapse

Constructor Details

#initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES) ⇒ Resource

Returns a new instance of Resource.

Parameters:

  • http_client (Faraday::Connection, nil) (defaults to: nil)

    HTTP client (SSRF-hardened default if nil)

  • timeout (Integer) (defaults to: 10)

    request timeout in seconds (default: 10)

  • max_body_bytes (Integer) (defaults to: Fetcher::DEFAULT_MAX_BODY_BYTES)

    bounded read cap in bytes



11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# File 'lib/basecamp/oauth/resource.rb', line 11

def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES)
  Fetcher.ensure_redirects_suppressed!(http_client) if http_client
  # Normalize before building the client and before the fetch computes its
  # wall-clock deadline: a non-finite/non-positive timeout must not disable
  # either bound (see Fetcher.normalize_timeout).
  @timeout = Fetcher.normalize_timeout(timeout)
  @http_client = http_client || Fetcher.build_client(@timeout)
  # Normalize the public cap to a finite non-negative Integer: a nil, float,
  # or Float::INFINITY would otherwise disable the streaming memory bound
  # (an infinite/undefined cap never trips), reintroducing an SSRF/OOM risk.
  @max_body_bytes =
    if max_body_bytes.is_a?(Integer) && max_body_bytes >= 0
      max_body_bytes
    else
      Fetcher::DEFAULT_MAX_BODY_BYTES
    end
end

Instance Method Details

#discover(resource_origin) ⇒ ProtectedResourceMetadata

Discovers protected-resource metadata from {resource_origin}/.well-known/oauth-protected-resource. resource is required and must equal the requested origin by code-point; authorization_servers is preserved distinctly as absent (+nil+) vs present-empty (+[]+).

Parameters:

  • resource_origin (String)

    the API/resource host origin

Returns:

Raises:



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/basecamp/oauth/resource.rb', line 39

def discover(resource_origin)
  origin = Basecamp::Security.require_origin_root!(resource_origin, "resource origin")
  url = "#{origin}/.well-known/oauth-protected-resource"
  data = Fetcher.fetch_json(@http_client, url, timeout: @timeout, max_body_bytes: @max_body_bytes)

  resource = data["resource"]
  # Type-check, don't just probe truthiness: a wrong-typed resource (number,
  # object, array) is malformed metadata, and calling +.empty?+ on it would
  # raise a NoMethodError rather than a clean api_error.
  if !resource.is_a?(String) || resource.empty?
    raise OauthError.new("api_error", "Invalid resource metadata: missing required field: resource")
  end

  # Bind the resource identifier to the requested identifier (the raw caller
  # origin), code-point exact, NO normalization (RFC 9728 §3.3, SPEC.md §16):
  # the well-known URL is built from the normalized +origin+, but the metadata
  # +resource+ must be identical to what the caller supplied.
  unless resource == resource_origin
    raise OauthError.new(
      "api_error",
      "Resource identifier mismatch: metadata resource #{resource.inspect} does not equal #{resource_origin.inspect}"
    )
  end

  ProtectedResourceMetadata.new(
    resource: resource,
    authorization_servers: extract_authorization_servers(data)
  )
end