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)

    injected Faraday client (kept, verified redirect-free); nil selects the default headers-first Net::HTTP transport (Fetcher.stream_http)

  • timeout (Numeric) (defaults to: 10)

    request timeout in seconds (default: 10); an invalid value falls back via Fetcher.normalize_timeout

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

    bounded read cap in bytes



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

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 storing (nil selects the headers-first Net::HTTP
  # transport; an injected client is kept) 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)
  # nil selects the headers-first {Fetcher.stream_http} transport (total
  # wall-clock bound incl. the header phase); an injected connection keeps
  # the Faraday path, verified redirect-free above.
  @http_client = http_client
  # 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.
  # Shared with Discovery and the device flow.
  @max_body_bytes = Fetcher.normalize_body_cap(max_body_bytes)
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:



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
68
69
70
# File 'lib/basecamp/oauth/resource.rb', line 42

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